﻿/*
 * Copyright 2009-2011 Edward L. Stauff.                  Contact: <EdStauff@gmail.com>.
 * 
 * This file, "Assertion.cs", is part of the "Stauffware.Common.Time" class library.
 * "Stauffware.Common.Time" is free software: you can redistribute it and/or 
 * modify it under the terms of the GNU Lesser Public License as published by the 
 * Free Software Foundation, either version 3 of the License, or (at your option) 
 * any later version.
 * 
 * "Stauffware.Common.Time" is distributed in the hope that it will be useful, 
 * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser Public License for more 
 * details.
 * 
 * You should have received a copy of the GNU Lesser Public License along with 
 * the "Stauffware.Common.Time" sources.  If not, see <http://www.gnu.org/licenses/>.
 */

using System;
using System.Globalization;
using Stauffware.Common.Core;

namespace Stauffware.Common.Time
{
	/// <summary>
	/// Options for parsing times (but not dates; see DateParseOptions, DateTimeParseOptions).
	/// </summary>
	[Flags]
	public enum TimeParseOptions : long
	{
		/// <summary>
		/// No options.
		/// </summary>
		None = 0,

		#region ParseOptions compatibility

		/// <summary>
		/// Allow leading whitespace.  (Equal to ParseOptions.AllowLeadingWhite.)
		/// </summary>
		AllowLeadingWhite	= ParseOptions.AllowLeadingWhite,

		/// <summary>
		/// Allow trailing whitespace.  (Equal to ParseOptions.AllowTrailingWhite.)
		/// </summary>
		AllowTrailingWhite	= ParseOptions.AllowTrailingWhite,

		/// <summary>
		/// Allow inner whitespace (but never between digits or time separator).
		/// (Equal to ParseOptions.AllowInnerWhite.)
		/// </summary>
		AllowInnerWhite		= ParseOptions.AllowInnerWhite,

		/// <summary>
		/// Equivalent to combining AllowLeadingWhite, AllowInnerWhite, and AllowTrailingWhite.
		/// (Equal to ParseOptions.AllowWhiteSpace.)
		/// </summary>
		AllowWhiteSpace		= ParseOptions.AllowWhiteSpace,

		/// <summary>
		/// Forces parsing of the entire input string.
		/// Without this option, extraneous characters following a valid time will
		/// terminate the parsing, but not cause an error.
		/// (Equal to ParseOptions.ParseEntireInput.)
		/// </summary>
		ParseEntireInput	= ParseOptions.ParseEntireInput,

		#endregion

		/// <summary>
		/// AM/PM indicator is not allowed.  Cannot be combined with RequireAmPm.
		/// </summary>
		ForbidAmPm		= 0x1000000,

		/// <summary>
		/// AM/PM indicator is required.  Cannot be combined with ForbidAmPm.
		/// </summary>
		RequireAmPm		= 0x2000000,

		/// <summary>
		/// Seconds are allowed.
		/// </summary>
		AllowSeconds	= 0x4000000,

		/// <summary>
		/// Fractional seconds are allowed.
		/// </summary>
		AllowFraction	= 0x8000000,

		/// <summary>
		/// Timezone indicator is not allowed.  Cannot be combined with RequireTimeZone.
		/// </summary>
		ForbidTimeZone	= 0x10000000,

		/// <summary>
		/// Timezone indicator is required.  Cannot be combined with ForbidTimeZone.
		/// </summary>
		RequireTimeZone	= 0x20000000,

		/// <summary>
		/// If timezone not specified, assume local time.
		/// </summary>
		AssumeLocal = 0x40000000,

		/// <summary>
		/// If timezone not specified, assume UTC.
		/// </summary>
		AssumeUniversal = 0x80000000,

		/// <summary>
		/// Require time separator characters (e.g. ':').  If this flag is not set,
		/// then a time can be specified as exactly 4 digits, or as exactly 6 digits
		/// with an optional decimal point and fractional seconds.
		/// </summary>
		RequireSeparators = 0x1 << 32,

		/// <summary>
		/// The keyword "now" is allowed as a shortcut for the current local time of day.
		/// </summary>
		AllowNow		= 0x2 << 32,
	}

	//#################################################################################
	/// Extension methods

	public static class TimeParseOptions_Extensions
	{
		/// <summary>
		/// Converts a ParseOptions value to a TimeParseOptions value.
		/// </summary>
		/// <param name="o"></param>
		/// <returns></returns>
		public static TimeParseOptions ToTimeParseOptions (this ParseOptions o)
		{
			return (TimeParseOptions)o;
		}
	}

	//#################################################################################
	/// <summary>
	/// A parser for times (time of day).
	/// Does not handle dates (see DateParse, DateTimeParse).
	/// </summary>
	/// <remarks>
	/// Time syntax:
	///	time	:= [ws] hour sep min [sep sec [dp frac]] [ws] [ampm] [ws] [zone] [ws]
	///	hour	:= digit [digit]
	///	min		:= digit [digit]
	///	sec		:= digit [digit]
	///	frac	:= digit...
	///	ws		:= { whitespace }
	///	sep		:= { localized time separator; e.g. ":" }
	///	dp		:= { localized decimal point; e.g. "." }
	///	zone	:= { timezone; not supported yet }
	/// </remarks>

	public class TimeParser
	{
		/// <summary>
		/// Enum bits reserved for TimeParseOptions.  
		/// "Derived" parse option enums must not redefine these.
		/// </summary>
		internal const long Reserved_ParseOptions_Bits = 0xFFF000000;

		/// <summary>
		/// What to use when there isn't a DateParseOptions parameter.
		/// </summary>
		public const TimeParseOptions DefaultParseOptions = 
				TimeParseOptions.AllowWhiteSpace | TimeParseOptions.ParseEntireInput;


		private readonly string Separators = ":.";
		private readonly TimeParseOptions Options;
		private readonly NumberFormatInfo NumFmtInfo;
		private readonly DateTimeFormatInfo DateTimeFmtInfo;

		//-----------------------------------------------------------------------------

		/// <summary>
		/// Constructs a parser that uses DefaultParseOptions.
		/// </summary>
		public TimeParser ( ) : this(DefaultParseOptions, null) { }

		/// <summary>
		/// Constructs a parser.
		/// </summary>
		/// <param name="options">parsing options</param>
		public TimeParser (TimeParseOptions options) : this(options, null) { }

		/// <summary>
		/// Constructs a parser that uses DefaultParseOptions.
		/// </summary>
		/// <param name="ifp">formatting info, or null</param>
		public TimeParser (IFormatProvider ifp) : this(DefaultParseOptions, ifp) { }

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Constructs a parser.
		/// </summary>
		/// <param name="options">parsing options</param>
		/// <param name="ifp">format provider</param>

		public TimeParser (TimeParseOptions options, IFormatProvider ifp)
		{
			Require.Condition(!options.HasFlag(TimeParseOptions.ForbidAmPm | TimeParseOptions.RequireAmPm),
								"ForbidAmPm and RequireAmPm cannot be use together");
			Require.Condition(!options.HasFlag(TimeParseOptions.ForbidTimeZone | TimeParseOptions.RequireTimeZone),
								"ForbidTimeZone and RequireTimeZone cannot be use together");
			Require.Condition(!options.HasFlag(TimeParseOptions.AssumeLocal | TimeParseOptions.AssumeUniversal),
								"AssumeLocal and AssumeUniversal cannot be use together");

			this.Options = options;

			this.NumFmtInfo = NumberFormatInfo.GetInstance(ifp);
			this.DateTimeFmtInfo = DateTimeFormatInfo.GetInstance(ifp);
			this.Separators = TimeResources.TimeSeparators;
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Parses a time.  
		/// Should never throw an exception except for failed assertions (programming errors).
		/// </summary>
		/// <param name="input">the string to be parsed</param>
		/// <returns>
		///		The result of the parse attempt, whether successful or not; will never return null.
		/// </returns>

		public TimeParse Parse(string input)
		{
			Require.NotNull(input);

			TimeParse parse = new TimeParse();

			int next = SkipWhiteSpaceIf(this.Options.HasFlag(TimeParseOptions.AllowLeadingWhite), input, 0, parse);

			if (this.Options.HasFlag(TimeParseOptions.AllowTrailingWhite | TimeParseOptions.ParseEntireInput))
				input = input.TrimEnd();

			int hour = -1;
			int minute = -1;
			int second = -1;

			// look for hour, minute, second (but not fractional second yet)
			if (!this.LexHourMinSec(input, ref next, this.Options, parse, out hour, out minute, out second))
			{
				parse._parsedText = input.Substring(0, next);
				return parse;
			}

			int millisecond = 0;

			// if there's any input left and we found seconds, look for fractional seconds
			if ((next < input.Length) && (second >= 0) && this.Options.HasFlag(TimeParseOptions.AllowFraction))
			{
				if (input.StartsWith(this.NumFmtInfo.NumberDecimalSeparator))
				{
					int dp = next;
					next += this.NumFmtInfo.NumberDecimalSeparator.Length;
					int digCnt = NumberLexer.LexDigits(input, next);
					next += digCnt;
					if (digCnt > 0)
					{
						int len = next - dp;
						if (len > 4) len = 4;
						millisecond = int.Parse(input.Substring(dp, len));
						switch (len)
						{
							case 1: millisecond *= 100; break;
							case 2: millisecond *= 10; break;
							case 4: 
								millisecond = (millisecond + 5) / 10; // round off
								len = 3;
								break; 
						}
						parse._format += "fff".Substring(0, len);
					}
				}
			}

			// look for am/pm


			bool? pm = null;	// false = am, true = pm, null = neither found

			next = SkipWhiteSpaceIf(this.Options.HasFlag(TimeParseOptions.AllowInnerWhite), input, next, parse);

			if (Match(input, ref next, this.DateTimeFmtInfo.AMDesignator))
				pm = false;
			else if (Match(input, ref next, this.DateTimeFmtInfo.PMDesignator))
				pm = true;

			if (pm.HasValue)
				parse._format += "tt";

			if (this.Options.HasFlag(TimeParseOptions.ForbidAmPm) && pm.HasValue)
			{
				parse._errorMessage = "invalid time; am/pm not allowed";
				parse._parsedText = input.Substring(0, next);
				return parse;
			}

			if (this.Options.HasFlag(TimeParseOptions.RequireAmPm) && !pm.HasValue)
			{
				parse._errorMessage = "invalid time; expecting required am/pm";
				parse._parsedText = input.Substring(0, next);
				return parse;
			}

			// validate the numbers

			if (pm.HasValue)
			{
				if (hour == 0 || hour > 12)
				{
					parse._errorMessage = "invalid time; with am/pm, hour must be between 1 and 12";
					return parse;
				}
				if ((hour == 12) && !pm.Value)
					hour = 0;
			}
			else
			{
				parse._format = parse._format.Replace('h', 'H');

				if (hour > 23)
				{
					parse._errorMessage = "invalid time; hour must be between 0 and 23";
					return parse;
				}
			}

			if (minute >= 60)
			{
				parse._errorMessage = "invalid time; minute must be < 60";
				return parse;
			}

			if (second >= 60)
			{
				parse._errorMessage = "invalid time; second must be < 60";
				return parse;
			}
			else if (second < 0)
				second = 0;		// no seconds found; default it


			DateTimeKind kind = DateTimeKind.Unspecified;
			if (this.Options.HasFlag(TimeParseOptions.AssumeLocal))
				kind = DateTimeKind.Local;
			else if (this.Options.HasFlag(TimeParseOptions.AssumeUniversal))
				kind = DateTimeKind.Utc;

			// TO-DO: look for timezone

			/*
			 * MS doc for DateTime.Parse says:
			 *		A time zone specifier (such as Z or GMT to indicate Coordinated Universal 
			 *		Time (UTC)) can also be present. In addition, s can include a time zone 
			 *		offset (such as "2008-10-1T6:00:32-8:00"). In this case, the format of 
			 *		[the input string] must conform to the ISO 8601 standard. 
			*/

			parse._parsedText = input.Substring(0, next);
			parse._value = new DateTime(1, 1, 1, hour, minute, second, millisecond, kind);

			// we only really care about inner whitespace in the format
			parse._format = parse._format.Trim();

			return parse;
		}

		//-----------------------------------------------------------------------------
		// Tries to lex the hour, minute and (optionally) whole-number second (but not 
		// the fractional second).  Returns true on success, false on failure.

		private bool LexHourMinSec (string input, ref int next, TimeParseOptions options,
									TimeParse parse, out int hour, out int minute, out int second)
		{
			int digCnt = NumberLexer.LexDigits(input, next);

			hour = -1;
			minute = -1;
			second = -1;

			if (!options.HasFlag(TimeParseOptions.RequireSeparators))
			{
				if (digCnt == 4)
				{
					hour = int.Parse(input.Substring(next, 2));
					minute = int.Parse(input.Substring(next+2, 2));
					next += digCnt;
					parse._format = "hhmm";
					return true;
				}
				else if (digCnt == 6)
				{
					hour = int.Parse(input.Substring(next, 2));
					minute = int.Parse(input.Substring(next+2, 2));
					second = int.Parse(input.Substring(next+4, 2));
					next += digCnt;
					parse._format = "hhmmss";
					return true;
				}
			}

			// didn't match special case of 4 or 6 digits

			// check for hour

			if (digCnt == 0)	// didn't find any digits)
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput) && (input != ""))
					parse._errorMessage = "invalid time; expecting digits";
				return false;
			}
			if (digCnt > 2)	// found too many digits
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput) && (input != ""))
					parse._errorMessage = "invalid time; too many hour digits";
				return false;
			}

			hour = int.Parse(input.Substring(next, digCnt));
			next += digCnt;
			parse._format = "hh".Substring(0, digCnt);

			// look for hour/minute separator
			if (!Separators.Contains(input[next]))
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput))
					parse._errorMessage = "invalid time; expecting separator after hour";
				return false;
			}

			parse._format += ":";
			next++;	// skip separator

			// look for minute

			digCnt = NumberLexer.LexDigits(input, next);

			if (digCnt == 0)	// didn't find any minute digits
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput) && (input != ""))
					parse._errorMessage = "invalid time; expecting minute digits";
				return false;
			}
			if (digCnt > 2)		// found too many minute digits
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput) && (input != ""))
					parse._errorMessage = "invalid time; too many minute digits";
				return false;
			}

			minute = int.Parse(input.Substring(next, digCnt));
			next += digCnt;
			parse._format += "mm".Substring(0, digCnt);

			// look for second

			if (next >= input.Length)
				return true;

			// look for minute/second separator
			if (!Separators.Contains(input[next]))
				return true;

			parse._format += ":";
			next++;	// skip separator

			if (!options.HasFlag(TimeParseOptions.AllowSeconds))
			{
				parse._errorMessage = "invalid time; seconds not allowed";
				return false;
			}

			digCnt = NumberLexer.LexDigits(input, next);

			if (digCnt == 0)	// didn't find any seconds digits
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput) && (input != ""))
					parse._errorMessage = "invalid time; expecting seconds digits";
				return false;
			}
			if (digCnt > 2)		// found too many seconds digits
			{
				if (options.HasFlag(TimeParseOptions.ParseEntireInput) && (input != ""))
				{
					parse._errorMessage = "invalid time; too many seconds digits";
					next += 2;
				}
				return false;
			}

			second = int.Parse(input.Substring(next, digCnt));
			next += digCnt;
			parse._format += "ss".Substring(0, digCnt);

			return true;
		}

		//-----------------------------------------------------------------------------
		// If condition is true, skips whitespace starting at the given index in the
		// given string.  Returns the new index.

		private int SkipWhiteSpaceIf (bool condition, string input, int index, TimeParse parse)
		{
			int oldIndex = index;
			if (condition)
				while ((index < input.Length) && char.IsWhiteSpace(input[index]))
					index++;

			if (index > oldIndex)
				parse._format += " ";

			return index;
		}

		//-----------------------------------------------------------------------------
		// Compares the given keyword with the input string at the given offset, ignoring
		// case.  If it matches, offset is incremented by the length of the keyword.

		private static bool Match (string input, ref int offset, string keyword)
		{
			if (0 != string.Compare(input, offset, keyword, 0, keyword.Length,
										StringComparison.CurrentCultureIgnoreCase))
				return false;
			offset += keyword.Length;
			return true;
		}

		//-----------------------------------------------------------------------------
	}

	//#################################################################################
	/// <summary>
	/// Encapsulates the result of trying to parse a time from a string into a DateTime.
	/// Does not include a date; see DateParse, DateTimeParse.
	/// </summary>

	public class TimeParse : ValueParse<DateTime>
	{
		/// <summary>
		/// The format of the successfully parsed time:
		/// 'HH' = 2-digit hour (13-23), 'h' = 1-digit hour (1-12), 
		/// 'H' = 1-digit hour (0), 'm' = 1-digit minute, 'mm' = 2-digit minute,
		/// 's' = 1-digit second, 'ss' = 2-digit second,
		/// 'f' = fraction of a second (one 'f' for each digit in the input),
		/// 't' = 1-character AM/PM designator (lower case),
		/// 'tt' = 2-character AM/PM designator (lower case),
		/// 'T' = 1-character AM/PM designator (upper case),
		/// 'TT' = 2-character AM/PM designator (upper case),
		/// 'K' = timezone designator, ':' = localized time separator character,
		/// '.' = localized decimal point character,
		/// ' ' = one or more whitespace characters.
		/// </summary>
		public string Format { get { return this._format; } }
		internal string _format;
	}

	//#################################################################################

}
